Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 32e97c105419d3bcede5c1311df3ffb63b8305a4


Parents : 5419fab
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-24T07:38:26-05:00

feat: various fixes found by text oracles

Changes

33 files changed, 1048 insertions(+), 59 deletions(-)


Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f1b5f4e..75412fce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -69,6 +69,15 @@ All notable changes to this project will be documented in this file.
### Fixed
- Web Sync Messages after a backgrounded browser tab: recover stale WebSocket as a shell reconnect, refresh CSRF, and do not abort sync when request-path priming fails
+- Conversations: re-opening an already-read thread no longer decrements the Messages unread badge
+- Notifications: DND still updates the Messages unread badge (DND only suppresses OS notifications and sound)
+- Identity switch clears Relay Chat, NomadNet browser tabs, Map tabs, and RNSH session UI so keep-alive pages cannot show the previous identity
+- Map local URL checks use RFC1918 172.16/12 instead of treating all 172.* hosts as local
+- Messages unread badge no longer overwritten by a paginated or filtered conversation page count
+- Deep-link or compose open marks the conversation read so the sidebar unread state clears
+- Map my-location prefers LXMF address hash telemetry (not only identity hash)
+- RNode flasher SRI lookup matches flat and nested integrity.json keys so zip.min.js loads
+- RRC kick/ban and failed auto-rejoin clear unread_counts so room pills do not stick
- Desktop AppImage: main-process logs always append to the storage logs folder (meshchatx.log). Stdout is only used when a terminal is attached, with broken-pipe guards as a fallback, so background launches no longer raise write EPIPE dialogs
- Android: lxmfy packaging, flock soft-lock, splash/logo clipping, Landlock skipped on Android
- Android RNode BLE/USB via Chaquopy

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 142f47ff..2f0fb442 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/src/backend/http/routes/rrc.py b/meshchatx/src/backend/http/routes/rrc.py
index a1bc111c..3ca87faf 100644
--- a/meshchatx/src/backend/http/routes/rrc.py
+++ b/meshchatx/src/backend/http/routes/rrc.py
@@ -271,6 +271,19 @@ def register_rrc_routes(routes, app):
)
return web.json_response({"hub": hub.to_dict()})
+ @routes.post("/api/v1/rrc/hubs/{hub_hash}/rooms/list")
+ async def rrc_hub_rooms_list(request):
+ _, hub, error = _rrc_require_hub(request.match_info.get("hub_hash", ""))
+ if error is not None:
+ return error
+ try:
+ hub.request_room_list()
+ except (ValueError, RuntimeError) as e:
+ return web.json_response({"message": str(e)}, status=400)
+ return web.json_response(
+ {"message": "Room list requested", "hub": hub.to_dict()}
+ )
+
@routes.post("/api/v1/rrc/hubs/{hub_hash}/connect")
async def rrc_hub_connect(request):
_, hub, error = _rrc_require_hub(request.match_info.get("hub_hash", ""))

diff --git a/meshchatx/src/backend/rrc/manager.py b/meshchatx/src/backend/rrc/manager.py
index 0a660e02..09c5045f 100644
--- a/meshchatx/src/backend/rrc/manager.py
+++ b/meshchatx/src/backend/rrc/manager.py
@@ -254,7 +254,7 @@ class RRCHub:
self.manager._notify_change(self)
def _bump_unread(self, room):
- if not room:
+ if not room or room not in self.rooms:
return
self.unread_rooms.add(room)
self.unread_counts[room] = min(9999, self.unread_counts.get(room, 0) + 1)
@@ -500,15 +500,26 @@ class RRCHub:
if should_list:
self._request_room_list()
- def _request_room_list(self):
+ def request_room_list(self):
+ """Request a fresh public room list from the hub (/list).
+
+ The hub reply replaces available_rooms (adds new rooms and drops
+ removed ones). The list notice is silent so it does not appear in
+ chat history.
+ """
+ with self._lock:
+ self._silent_list_pending += 1
try:
- with self._lock:
- self._silent_list_pending += 1
self.send_command("/list", room=None, record_local=False)
except Exception:
with self._lock:
if self._silent_list_pending > 0:
self._silent_list_pending -= 1
+ raise
+
+ def _request_room_list(self):
+ with contextlib.suppress(Exception):
+ self.request_room_list()
def set_auto_who(self, enabled, save=True):
with self._lock:
@@ -1261,12 +1272,16 @@ class RRCHub:
self._pending_parts.discard(r)
if rollback_join:
self.rooms.discard(r)
+ self.unread_rooms.discard(r)
+ self.mention_rooms.discard(r)
+ self.unread_counts.pop(r, None)
elif self.manager.is_forced_leave_error(text) and r in self.rooms:
forced_leave = True
self.rooms.discard(r)
self.members.pop(r, None)
self.unread_rooms.discard(r)
self.mention_rooms.discard(r)
+ self.unread_counts.pop(r, None)
if rollback_join or forced_leave:
self.manager.save()
# Drop a remembered key that the hub rejected so reconnect does not loop.

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 45fa5161..e8e05eda 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1640,9 +1640,6 @@ export default {
this.updateRelayChatUnreadCount();
},
"lxmf.delivery": async (json) => {
- if (this.config?.do_not_disturb_enabled) {
- return;
- }
if (json.sieve_suppress_notifications) {
return;
}
@@ -1663,7 +1660,8 @@ export default {
userFacing,
};
- // Open peers are mark-as-read by ConversationViewer. Still refresh for other peers.
+ // DND suppresses OS notifications and sound only. Unread badge must
+ // still refresh so the Messages nav does not freeze while DND is on.
if (isIncoming && userFacing && !sourceOpen) {
this.updateUnreadConversationsCount();
}

diff --git a/meshchatx/src/frontend/components/map/MapBrowser.vue b/meshchatx/src/frontend/components/map/MapBrowser.vue
index e4289e57..8780afd6 100644
--- a/meshchatx/src/frontend/components/map/MapBrowser.vue
+++ b/meshchatx/src/frontend/components/map/MapBrowser.vue
@@ -80,6 +80,7 @@
import MapPage from "./MapPage.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import TileCache from "../../js/TileCache";
+import GlobalEmitter from "../../js/GlobalEmitter";
import { loadMapTabs, saveMapTabs } from "../../js/browserLayoutStore";
const LEGACY_MAP_STATE_KEY = "last_view";
@@ -135,6 +136,7 @@ export default {
async mounted() {
this.setupViewportWatcher();
window.addEventListener("keydown", this.handleKeydown, true);
+ GlobalEmitter.on("identity-switched", this.onIdentitySwitched);
if (!(await this.restoreTabs())) {
const storageId = createStorageId();
@@ -143,10 +145,18 @@ export default {
}
},
beforeUnmount() {
+ GlobalEmitter.off("identity-switched", this.onIdentitySwitched);
this.teardownViewportWatcher();
window.removeEventListener("keydown", this.handleKeydown, true);
},
methods: {
+ onIdentitySwitched() {
+ this.tabs = [];
+ this.activeTabId = null;
+ saveMapTabs({ tabs: [], activeIndex: 0 });
+ const storageId = createStorageId();
+ void this.addTab(null, true, storageId);
+ },
setupViewportWatcher() {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
this.isWideViewport = false;

diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index 8696cfa6..bdbf6c92 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -1056,6 +1056,7 @@ import { readKmzToFeatures, writeFeaturesToKmzBlob } from "../../js/mapExchange/
import { getDrawFeatureMetadataPayload, getFeatureAnchorCoordinate } from "../../js/mapExchange/metadataUtils.js";
import { styleFromMcxProperties } from "../../js/mapExchange/styleFromProperties.js";
import { computeSegmentMetrics, buildBearingOverlayHtml, buildBearingLiveTooltipHtml } from "../../js/mapGeodesy.js";
+import { isLocalMapServiceUrl } from "../../js/mapLocalUrl.js";
const OPENFREEMAP_DEFAULT_STYLE = "https://tiles.openfreemap.org/styles/bright";
const DEFAULT_OSM_RASTER = DEFAULT_TILE_SERVER_URL;
@@ -2139,22 +2140,7 @@ export default {
}
},
isLocalUrl(url) {
- if (!url) return false;
- try {
- const urlObj = new URL(url, window.location.origin);
- return (
- urlObj.hostname === "localhost" ||
- urlObj.hostname === "127.0.0.1" ||
- urlObj.hostname === "::1" ||
- urlObj.hostname.startsWith("192.168.") ||
- urlObj.hostname.startsWith("10.") ||
- urlObj.hostname.startsWith("172.") ||
- urlObj.hostname.endsWith(".local") ||
- url.startsWith("/")
- );
- } catch {
- return url.startsWith("/") || url.startsWith("./") || !url.startsWith("http");
- }
+ return isLocalMapServiceUrl(url, typeof window !== "undefined" ? window.location.origin : "");
},
isDefaultOnlineUrl(url) {
if (!url) return false;
@@ -4043,8 +4029,15 @@ export default {
return { lon, lat };
}
}
- if (this.config && this.config.identity_hash) {
- const myTelemetry = this.telemetryList.find((t) => t.destination_hash === this.config.identity_hash);
+ if (this.config && (this.config.lxmf_address_hash || this.config.identity_hash)) {
+ const myHashes = new Set(
+ [this.config.lxmf_address_hash, this.config.identity_hash]
+ .filter(Boolean)
+ .map((h) => String(h).toLowerCase())
+ );
+ const myTelemetry = this.telemetryList.find((t) =>
+ myHashes.has(String(t.destination_hash || "").toLowerCase())
+ );
if (myTelemetry && myTelemetry.telemetry?.location) {
const loc = myTelemetry.telemetry.location;
if (loc.longitude != null && loc.latitude != null) {
@@ -4691,9 +4684,16 @@ export default {
}
}
- // Priority 2: Use telemetry data if available for our own hash
- if (this.config && this.config.identity_hash) {
- const myTelemetry = this.telemetryList.find((t) => t.destination_hash === this.config.identity_hash);
+ // Priority 2: Use telemetry data if available for our LXMF or identity hash
+ if (this.config && (this.config.lxmf_address_hash || this.config.identity_hash)) {
+ const myHashes = new Set(
+ [this.config.lxmf_address_hash, this.config.identity_hash]
+ .filter(Boolean)
+ .map((h) => String(h).toLowerCase())
+ );
+ const myTelemetry = this.telemetryList.find((t) =>
+ myHashes.has(String(t.destination_hash || "").toLowerCase())
+ );
if (myTelemetry && myTelemetry.telemetry?.location) {
const loc = myTelemetry.telemetry.location;
this.map.getView().animate({

diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 949b54a5..9b4b915e 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -599,6 +599,18 @@ export default {
},
methods: {
syncUnreadCount() {
+ // Nav badge must track the server total from /api/v1/notifications.
+ // Overwriting it with this page's loaded rows undercounts when there are
+ // more conversations, an unread filter, a folder, or an active search.
+ const listIsPartial =
+ this.hasMoreConversations ||
+ this.filterUnreadOnly ||
+ this.selectedFolderId != null ||
+ Boolean(this.conversationSearchTerm && this.conversationSearchTerm.trim());
+ if (listIsPartial) {
+ GlobalEmitter.emit("notifications-changed");
+ return;
+ }
GlobalState.unreadConversationsCount = countUnreadConversations(this.conversations);
},
onIdentitySwitched() {
@@ -616,6 +628,7 @@ export default {
pane.peer = null;
}
}
+ this.getConfig();
this.getConversations();
this.getFolders();
this.loadConversationPins();
@@ -648,6 +661,7 @@ export default {
const existingPeer = this.peers[destinationHash];
if (existingPeer) {
this.onPeerClick(existingPeer);
+ this.dismissUnreadForOpenDestination(destinationHash);
return;
}
@@ -656,12 +670,16 @@ export default {
return;
}
- const existingConversation = this.conversations.find((c) => c.destination_hash === destinationHash);
+ const existingConversation = this.conversations.find(
+ (c) => Utils.normalizeMeshchatHashHex(c.destination_hash) === destinationHash
+ );
this.onPeerClick({
display_name: existingConversation?.display_name ?? "Anonymous Peer",
custom_display_name: existingConversation?.custom_display_name ?? null,
destination_hash: destinationHash,
+ is_unread: existingConversation?.is_unread === true,
});
+ this.dismissUnreadForOpenDestination(destinationHash);
},
async getConfig() {
try {
@@ -1284,18 +1302,23 @@ export default {
return;
}
// Viewer not mounted yet (restored panes). Optimistically clear local unread.
- if (conversation.is_unread) {
+ const wasUnread = conversation.is_unread === true;
+ if (wasUnread) {
conversation.is_unread = false;
}
Promise.resolve(window.api.post(`/api/v1/lxmf/conversations/${normalized}/mark-as-read`))
.then(() => {
GlobalEmitter.emit("notifications-changed");
NotificationUtils.clearMessageNotifications(normalized);
- if (GlobalState.unreadConversationsCount > 0) {
+ if (wasUnread && GlobalState.unreadConversationsCount > 0) {
GlobalState.unreadConversationsCount -= 1;
}
})
- .catch(() => {});
+ .catch(() => {
+ if (wasUnread) {
+ conversation.is_unread = true;
+ }
+ });
},
onCloseConversationViewer: function () {
// clear selected peer

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
index 984f9fdd..677585d3 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
@@ -217,6 +217,7 @@ export default {
}
GlobalEmitter.on("nomad-open-node", this.handleNomadOpenNode);
+ GlobalEmitter.on("identity-switched", this.onIdentitySwitched);
this.mountTab(this.activeTabId);
},
activated() {
@@ -226,10 +227,19 @@ export default {
},
beforeUnmount() {
GlobalEmitter.off("nomad-open-node", this.handleNomadOpenNode);
+ GlobalEmitter.off("identity-switched", this.onIdentitySwitched);
this.teardownViewportWatcher();
window.removeEventListener("keydown", this.handleKeydown, true);
},
methods: {
+ onIdentitySwitched() {
+ this.tabs = [];
+ this.activeTabId = null;
+ this.pageRefs = {};
+ this.mountedTabIds = {};
+ this.addTab();
+ saveNomadTabs({ tabs: [], activeIndex: 0 });
+ },
setupViewportWatcher() {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
this.isWideViewport = false;

diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
index 5dc6f227..2745cb73 100644
--- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
@@ -252,22 +252,41 @@
</li>
</ul>
- <div v-if="availableRoomsFor(hub).length > 0" class="space-y-0.5">
- <button
- type="button"
- class="flex w-full items-center gap-1 px-2.5 pt-1 text-left text-[10px] font-semibold uppercase tracking-wide text-sem-fg-muted transition-colors hover:text-sem-fg"
- @click="toggleAvailableRooms(hub.hub_hash)"
+ <div v-if="hub.connected || availableRoomsFor(hub).length > 0" class="space-y-0.5">
+ <div
+ class="flex w-full items-center gap-1 px-2.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-sem-fg-muted"
>
- <MaterialDesignIcon
- :icon-name="
- isAvailableRoomsExpanded(hub.hub_hash)
- ? 'chevron-down'
- : 'chevron-right'
- "
- class="size-3.5 shrink-0"
- />
- <span class="truncate">{{ $t("relay_chat.available_rooms") }}</span>
- </button>
+ <button
+ type="button"
+ class="flex min-w-0 flex-1 items-center gap-1 text-left transition-colors hover:text-sem-fg"
+ @click="toggleAvailableRooms(hub.hub_hash)"
+ >
+ <MaterialDesignIcon
+ :icon-name="
+ isAvailableRoomsExpanded(hub.hub_hash)
+ ? 'chevron-down'
+ : 'chevron-right'
+ "
+ class="size-3.5 shrink-0"
+ />
+ <span class="truncate">{{ $t("relay_chat.available_rooms") }}</span>
+ </button>
+ <button
+ type="button"
+ class="inline-flex size-5 shrink-0 items-center justify-center rounded-md text-sem-fg-muted transition-colors hover:bg-sem-surface/60 hover:text-sem-accent disabled:cursor-not-allowed disabled:opacity-40"
+ :disabled="!hub.connected || isRefreshingAvailableRooms(hub.hub_hash)"
+ :title="$t('relay_chat.refresh_available_rooms')"
+ @click.stop="refreshAvailableRooms(hub)"
+ >
+ <MaterialDesignIcon
+ icon-name="refresh"
+ class="size-3.5"
+ :class="{
+ 'animate-spin': isRefreshingAvailableRooms(hub.hub_hash),
+ }"
+ />
+ </button>
+ </div>
<ul v-show="isAvailableRoomsExpanded(hub.hub_hash)" class="space-y-0.5">
<li
v-for="availableRoom in availableRoomsFor(hub)"
@@ -1317,11 +1336,13 @@
import { nextTick } from "vue";
import WebSocketConnection from "../../js/WebSocketConnection";
import GlobalState from "../../js/GlobalState";
+import GlobalEmitter from "../../js/GlobalEmitter";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import Utils from "../../js/Utils";
import { DEFAULT_RRC_HUB_ICON, normalizeMdiIconName } from "../../js/mdiIconNames.js";
import { countRelayMentions } from "../../js/relayMentionCount.js";
+import { unjoinedAvailableRooms } from "../../js/rrcAvailableRooms.js";
import { filterRelayMembers, filterRelayMessages } from "../../js/relayMessageSearch.js";
import {
buildRelayMessageTimeline,
@@ -1453,6 +1474,7 @@ export default {
selectedRoom: null,
expandedHubs: {},
availableRoomsExpanded: {},
+ availableRoomsRefreshing: {},
hostUptimeTick: 0,
hostUptimeAnchorMs: 0,
hostUptimeTimer: null,
@@ -1642,6 +1664,7 @@ export default {
},
mounted() {
WebSocketConnection.on("message", this.onWebsocketMessage);
+ GlobalEmitter.on("identity-switched", this.onIdentitySwitched);
this.smMq = window.matchMedia("(min-width: 640px)");
this.smUp = this.smMq.matches;
this.smMq.addEventListener("change", this.onSmMqChange);
@@ -1663,6 +1686,7 @@ export default {
},
beforeUnmount() {
WebSocketConnection.off("message", this.onWebsocketMessage);
+ GlobalEmitter.off("identity-switched", this.onIdentitySwitched);
if (this.discoverySearchTimer) {
clearTimeout(this.discoverySearchTimer);
}
@@ -1678,6 +1702,24 @@ export default {
window.api.post("/api/v1/rrc/active/clear").catch(() => {});
},
methods: {
+ onIdentitySwitched() {
+ this.hubs = [];
+ this.serverHubs = [];
+ this.discovered = [];
+ this.messages = [];
+ this.members = [];
+ this.selectedHubHash = null;
+ this.selectedRoom = null;
+ this.expandedHubs = {};
+ this.availableRoomsExpanded = {};
+ this.availableRoomsRefreshing = {};
+ GlobalState.relayChatUnreadCount = 0;
+ this.fetchHubs();
+ this.fetchServers();
+ if (!this.isPopoutMode) {
+ this.fetchDiscovered();
+ }
+ },
selectView(view) {
if (view !== "host") {
this.closeHostModeration();
@@ -1742,14 +1784,7 @@ export default {
return hub.known_rooms;
},
availableRoomsFor(hub) {
- if (!hub || !hub.available_rooms || typeof hub.available_rooms !== "object") {
- return [];
- }
- const known = new Set(this.orderedRoomsFor(hub));
- return Object.entries(hub.available_rooms)
- .filter(([name]) => !known.has(name))
- .map(([name, topic]) => ({ name, topic }))
- .sort((a, b) => a.name.localeCompare(b.name));
+ return unjoinedAvailableRooms(hub?.available_rooms, this.orderedRoomsFor(hub));
},
onCollapsedHubClick(hub) {
const rooms = this.orderedRoomsFor(hub);
@@ -1774,6 +1809,28 @@ export default {
this.availableRoomsExpanded[hubHash] = !this.isAvailableRoomsExpanded(hubHash);
this.persistRelayLayout();
},
+ isRefreshingAvailableRooms(hubHash) {
+ return !!this.availableRoomsRefreshing[hubHash];
+ },
+ async refreshAvailableRooms(hub) {
+ if (!hub?.hub_hash || !hub.connected || this.isRefreshingAvailableRooms(hub.hub_hash)) {
+ return;
+ }
+ this.availableRoomsRefreshing = {
+ ...this.availableRoomsRefreshing,
+ [hub.hub_hash]: true,
+ };
+ try {
+ await window.api.post(`/api/v1/rrc/hubs/${hub.hub_hash}/rooms/list`);
+ ToastUtils.info(this.$t("relay_chat.rooms_list_requested"));
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
+ } finally {
+ const next = { ...this.availableRoomsRefreshing };
+ delete next[hub.hub_hash];
+ this.availableRoomsRefreshing = next;
+ }
+ },
hostedHubUptimeSeconds(hub) {
void this.hostUptimeTick;
if (!hub?.running) {

diff --git a/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue b/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue
index 0b76790a..b493bd78 100644
--- a/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue
@@ -280,6 +280,7 @@ import ToolsPageHeader from "./ToolsPageHeader.vue";
import RNSHSessionTerminal from "./RNSHSessionTerminal.vue";
import ToastUtils from "../../js/ToastUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
+import GlobalEmitter from "../../js/GlobalEmitter";
import { loadRnshLayout, saveRnshLayout } from "../../js/browserLayoutStore";
import { renderTerminalOutput } from "../../js/terminalRender";
@@ -404,6 +405,7 @@ export default {
this.restoreLayout();
await this.loadSessions();
WebSocketConnection.on("message", this.onWebsocketMessage);
+ GlobalEmitter.on("identity-switched", this.onIdentitySwitched);
},
beforeUnmount() {
if (this.onWindowResize) {
@@ -414,8 +416,16 @@ export default {
}
document.body.style.overflow = "";
WebSocketConnection.off("message", this.onWebsocketMessage);
+ GlobalEmitter.off("identity-switched", this.onIdentitySwitched);
},
methods: {
+ onIdentitySwitched() {
+ this.sessions = [];
+ this.outputsBySession = {};
+ this.selectedSessionId = null;
+ this.sessionFullscreen = false;
+ void this.loadSessions();
+ },
updateViewport() {
const narrow = typeof window !== "undefined" && window.innerWidth < NARROW_BREAKPOINT_PX;
this.isNarrowScreen = narrow;

diff --git a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
index db325457..013a98be 100644
--- a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
@@ -188,6 +188,7 @@ import { diagnose } from "../../js/rnode/Diagnostics.js";
import ToastUtils from "../../js/ToastUtils.js";
import ToolsPageHeader from "./ToolsPageHeader.vue";
+import { rnodeIntegrityKeyForSrc } from "../../js/rnode/rnodeIntegrityKey.js";
export default {
name: "RNodeFlasherPage",
@@ -365,12 +366,11 @@ export default {
async _loadScript(src) {
// Fetch and verify SRI before injecting
const integrity = this._rnodeIntegrity || (await this._loadRnodeIntegrity());
- const pathParts = src.split("/");
- const filename = pathParts.slice(-2).join("/"); // e.g., "zip.min.js" or "crypto-js@3.9.1-1/core.js"
+ const filename = rnodeIntegrityKeyForSrc(src, integrity);
const expectedHash = integrity?.[filename];
if (!expectedHash) {
- throw new Error(`RNode: SRI hash missing for ${filename}. Refusing to load untrusted code.`);
+ throw new Error(`RNode: SRI hash missing for ${filename || src}. Refusing to load untrusted code.`);
}
const res = await fetch(src);

diff --git a/meshchatx/src/frontend/js/mapLocalUrl.js b/meshchatx/src/frontend/js/mapLocalUrl.js
new file mode 100644
index 00000000..03086560
--- /dev/null
+++ b/meshchatx/src/frontend/js/mapLocalUrl.js
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Hostname classification for map tile / nominatim "local" checks.
+ */
+
+/**
+ * True when hostname is loopback, .local, or RFC1918 private.
+ * @param {string} hostname
+ * @returns {boolean}
+ */
+export function isPrivateOrLocalHostname(hostname) {
+ const host = String(hostname || "")
+ .trim()
+ .toLowerCase()
+ .replace(/^\[|\]$/g, "");
+ if (!host) {
+ return false;
+ }
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "0:0:0:0:0:0:0:1") {
+ return true;
+ }
+ if (host.endsWith(".local")) {
+ return true;
+ }
+ if (host.startsWith("10.")) {
+ return true;
+ }
+ if (host.startsWith("192.168.")) {
+ return true;
+ }
+ const m = /^172\.(\d+)\./.exec(host);
+ if (m) {
+ const second = Number(m[1]);
+ return second >= 16 && second <= 31;
+ }
+ return false;
+}
+
+/**
+ * True when a tile/nominatim URL should be treated as local / offline-reachable.
+ * @param {string} url
+ * @param {string} [origin]
+ * @returns {boolean}
+ */
+export function isLocalMapServiceUrl(url, origin = typeof window !== "undefined" ? window.location.origin : "") {
+ if (!url) {
+ return false;
+ }
+ const raw = String(url);
+ if (raw.startsWith("/") || raw.startsWith("./")) {
+ return true;
+ }
+ try {
+ const urlObj = new URL(raw, origin || "http://127.0.0.1");
+ return isPrivateOrLocalHostname(urlObj.hostname);
+ } catch {
+ return !raw.startsWith("http");
+ }
+}

diff --git a/meshchatx/src/frontend/js/rnode/rnodeIntegrityKey.js b/meshchatx/src/frontend/js/rnode/rnodeIntegrityKey.js
new file mode 100644
index 00000000..ad41dcbf
--- /dev/null
+++ b/meshchatx/src/frontend/js/rnode/rnodeIntegrityKey.js
@@ -0,0 +1,31 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Resolve integrity.json key for an RNode flasher script URL.
+ * Tries longest suffix first so both flat files (zip.min.js) and nested
+ * packages (crypto-js@x/core.js, web-serial-polyfill@x/dist/serial.js) match.
+ *
+ * @param {string} src
+ * @param {Record<string, string>|null|undefined} integrity
+ * @returns {string}
+ */
+export function rnodeIntegrityKeyForSrc(src, integrity) {
+ const parts = String(src || "")
+ .split("/")
+ .filter(Boolean);
+ if (parts.length === 0) {
+ return "";
+ }
+ const candidates = [];
+ for (let n = Math.min(3, parts.length); n >= 1; n -= 1) {
+ candidates.push(parts.slice(-n).join("/"));
+ }
+ if (integrity && typeof integrity === "object") {
+ for (const key of candidates) {
+ if (integrity[key]) {
+ return key;
+ }
+ }
+ }
+ return candidates[candidates.length - 1] || parts[parts.length - 1];
+}

diff --git a/meshchatx/src/frontend/js/rrcAvailableRooms.js b/meshchatx/src/frontend/js/rrcAvailableRooms.js
new file mode 100644
index 00000000..34a3cd67
--- /dev/null
+++ b/meshchatx/src/frontend/js/rrcAvailableRooms.js
@@ -0,0 +1,72 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+
+/**
+ * Unjoined public rooms for the Available Rooms sidebar.
+ *
+ * Oracle: entries in availableRooms whose names are not in knownRooms,
+ * sorted by room name.
+ */
+export function unjoinedAvailableRooms(availableRooms, knownRooms) {
+ if (!availableRooms || typeof availableRooms !== "object") {
+ return [];
+ }
+ const known = new Set(Array.isArray(knownRooms) ? knownRooms : []);
+ return Object.entries(availableRooms)
+ .filter(([name]) => typeof name === "string" && name && !known.has(name))
+ .map(([name, topic]) => ({
+ name,
+ topic: typeof topic === "string" && topic ? topic : null,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+}
+
+/**
+ * Diff two available_rooms snapshots after a hub /list refresh.
+ *
+ * A full list reply replaces the map. This oracle describes that replace
+ * as added, removed, and topic-updated room names.
+ */
+export function diffAvailableRooms(previous, next) {
+ const prev = previous && typeof previous === "object" && !Array.isArray(previous) ? previous : {};
+ const nxt = next && typeof next === "object" && !Array.isArray(next) ? next : {};
+ const prevKeys = new Set(Object.keys(prev));
+ const nextKeys = new Set(Object.keys(nxt));
+ const added = [];
+ const removed = [];
+ const updated = [];
+ for (const name of nextKeys) {
+ if (!prevKeys.has(name)) {
+ added.push(name);
+ } else if ((prev[name] || null) !== (nxt[name] || null)) {
+ updated.push(name);
+ }
+ }
+ for (const name of prevKeys) {
+ if (!nextKeys.has(name)) {
+ removed.push(name);
+ }
+ }
+ added.sort();
+ removed.sort();
+ updated.sort();
+ return { added, removed, updated };
+}
+
+/**
+ * Apply a refreshed hub room-list snapshot.
+ *
+ * Oracle: the next map fully replaces previous (add, remove, and topic update).
+ */
+export function applyAvailableRoomsSnapshot(_previous, next) {
+ if (!next || typeof next !== "object" || Array.isArray(next)) {
+ return {};
+ }
+ const out = {};
+ for (const [name, topic] of Object.entries(next)) {
+ if (typeof name !== "string" || !name) {
+ continue;
+ }
+ out[name] = typeof topic === "string" && topic ? topic : null;
+ }
+ return out;
+}

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index ed87e81f..22d6acb5 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -3602,6 +3602,8 @@
"no_hubs": "Noch keine Hubs konfiguriert. Fügen Sie einen hinzu, um zu beginnen.",
"no_rooms": "Keine Räume beigetreten. Treten Sie einem Raum bei, um zu chatten.",
"available_rooms": "Verfügbare Räume",
+ "refresh_available_rooms": "Verfügbare Räume aktualisieren",
+ "rooms_list_requested": "Verfügbare Räume werden aktualisiert",
"join": "Beitreten",
"no_room_selected": "Wählen Sie einen Raum, um Nachrichten anzuzeigen.",
"load_previous": "Frühere Nachrichten laden",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 6af8e02b..80a8b8b3 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -2904,6 +2904,8 @@
"no_hubs": "No hubs configured yet. Add one to get started.",
"no_rooms": "No rooms joined. Join a room to start chatting.",
"available_rooms": "Available Rooms",
+ "refresh_available_rooms": "Refresh available rooms",
+ "rooms_list_requested": "Refreshing available rooms",
"join": "Join",
"no_room_selected": "Select a room to view messages.",
"load_previous": "Load earlier messages",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index d0500051..0d4da48f 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -3602,6 +3602,8 @@
"no_hubs": "Aun no hay hubs configurados. Anade uno para empezar.",
"no_rooms": "No te has unido a ninguna sala. Unete a una para chatear.",
"available_rooms": "Salas disponibles",
+ "refresh_available_rooms": "Actualizar salas disponibles",
+ "rooms_list_requested": "Actualizando salas disponibles",
"join": "Unirse",
"no_room_selected": "Selecciona una sala para ver los mensajes.",
"load_previous": "Cargar mensajes anteriores",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index ff0f3861..cfb015a1 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -2904,6 +2904,8 @@
"no_hubs": "Ei vielä määritettyjä keskuksia. Lisää yksi aloittaaksesi.",
"no_rooms": "Et ole liittynyt yhteenkään huoneeseen. Liity huoneeseen aloittaaksesi keskustelun.",
"available_rooms": "Käytettävissä olevat huoneet",
+ "refresh_available_rooms": "Päivitä käytettävissä olevat huoneet",
+ "rooms_list_requested": "Päivitetään käytettävissä olevia huoneita",
"join": "Liity",
"no_room_selected": "Valitse huone nähdäksesi viestit.",
"load_previous": "Lataa aikaisempia viestejä",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 05835d6d..525ef58e 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -3602,6 +3602,8 @@
"no_hubs": "Aucun hub configure. Ajoutez-en un pour commencer.",
"no_rooms": "Aucun salon rejoint. Rejoignez un salon pour discuter.",
"available_rooms": "Salons disponibles",
+ "refresh_available_rooms": "Actualiser les salons disponibles",
+ "rooms_list_requested": "Actualisation des salons disponibles",
"join": "Rejoindre",
"no_room_selected": "Selectionnez un salon pour voir les messages.",
"load_previous": "Charger les messages precedents",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index aacf3926..e4d1d210 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -3602,6 +3602,8 @@
"no_hubs": "Nessun hub configurato. Aggiungine uno per iniziare.",
"no_rooms": "Nessuna stanza. Entra in una stanza per chattare.",
"available_rooms": "Stanze disponibili",
+ "refresh_available_rooms": "Aggiorna stanze disponibili",
+ "rooms_list_requested": "Aggiornamento stanze disponibili",
"join": "Entra",
"no_room_selected": "Seleziona una stanza per vedere i messaggi.",
"load_previous": "Carica messaggi precedenti",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 12d456bc..83549109 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -3602,6 +3602,8 @@
"no_hubs": "Nog geen hubs geconfigureerd. Voeg er een toe om te beginnen.",
"no_rooms": "Geen kamers. Word lid van een kamer om te chatten.",
"available_rooms": "Beschikbare kamers",
+ "refresh_available_rooms": "Beschikbare kamers vernieuwen",
+ "rooms_list_requested": "Beschikbare kamers worden vernieuwd",
"join": "Deelnemen",
"no_room_selected": "Selecteer een kamer om berichten te bekijken.",
"load_previous": "Eerdere berichten laden",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index a3575328..fe94893d 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -3602,6 +3602,8 @@
"no_hubs": "Хабы ещё не настроены. Добавьте один, чтобы начать.",
"no_rooms": "Нет комнат. Присоединитесь к комнате, чтобы общаться.",
"available_rooms": "Доступные комнаты",
+ "refresh_available_rooms": "Обновить доступные комнаты",
+ "rooms_list_requested": "Обновление доступных комнат",
"join": "Присоединиться",
"no_room_selected": "Выберите комнату для просмотра сообщений.",
"load_previous": "Загрузить более ранние сообщения",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 4073f371..0ac676f0 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -3602,6 +3602,8 @@
"no_hubs": "尚未配置中心。添加一个以开始。",
"no_rooms": "未加入任何房间。加入房间开始聊天。",
"available_rooms": "可用房间",
+ "refresh_available_rooms": "刷新可用房间",
+ "rooms_list_requested": "正在刷新可用房间",
"join": "加入",
"no_room_selected": "选择一个房间以查看消息。",
"load_previous": "加载更早的消息",

diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index 461c1e1d..e505bd03 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -1244,6 +1244,10 @@
"method": "POST",
"path": "/api/v1/rrc/hubs/{hub_hash}/rooms"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/list"
+ },
{
"method": "PUT",
"path": "/api/v1/rrc/hubs/{hub_hash}/rooms/order"

diff --git a/tests/backend/test_rnode_download_url_oracle.py b/tests/backend/test_rnode_download_url_oracle.py
new file mode 100644
index 00000000..d35890b4
--- /dev/null
+++ b/tests/backend/test_rnode_download_url_oracle.py
@@ -0,0 +1,32 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Oracle: RNode firmware download URL allowlist rejects lookalike hosts."""
+
+from __future__ import annotations
+
+
+def _url_allowed(url: str, allowed_prefixes: list[str]) -> bool:
+ return any(url.startswith(a) for a in allowed_prefixes)
+
+
+def test_rnode_download_prefix_oracle_rejects_lookalike_hosts():
+ allowed = [
+ "https://github.com/",
+ "https://codeload.github.com/",
+ "https://objects.githubusercontent.com/",
+ "https://release-assets.githubusercontent.com/",
+ ]
+ # Accept
+ assert _url_allowed(
+ "https://github.com/markqvist/RNode_Firmware/releases/download/v1/x.zip",
+ allowed,
+ )
+ assert _url_allowed(
+ "https://objects.githubusercontent.com/github-production-release-asset-2e65be/1/x",
+ allowed,
+ )
+ # Reject lookalikes / SSRF bait
+ assert not _url_allowed("https://github.com.evil.example/markqvist/x.zip", allowed)
+ assert not _url_allowed("https://evil.example/https://github.com/x.zip", allowed)
+ assert not _url_allowed("http://127.0.0.1/firmware.zip", allowed)
+ assert not _url_allowed("https://github.com.attacker/x", allowed)

diff --git a/tests/backend/test_rrc_api.py b/tests/backend/test_rrc_api.py
index 84c53f83..df498a0d 100644
--- a/tests/backend/test_rrc_api.py
+++ b/tests/backend/test_rrc_api.py
@@ -151,3 +151,35 @@ async def test_rrc_remove_hub(mock_app):
get = _find_handler(mock_app, "/api/v1/rrc/hubs", "GET")
listing = json.loads((await get(_make_request())).body)
assert listing["hubs"] == []
+
+
+@pytest.mark.asyncio
+async def test_rrc_rooms_list_requests_refresh(mock_app):
+ post_hub = _find_handler(mock_app, "/api/v1/rrc/hubs", "POST")
+ await post_hub(_make_request(json_body={"hub_hash": HUB_HASH_HEX}))
+ hub = mock_app.rrc_manager.find_hub_by_hex(HUB_HASH_HEX)
+ calls = []
+ hub.send_command = lambda text, room=None, record_local=True: calls.append(
+ (text, room, record_local),
+ )
+
+ handler = _find_handler(mock_app, "/api/v1/rrc/hubs/{hub_hash}/rooms/list", "POST")
+ assert handler is not None
+ response = await handler(_make_request(match_info={"hub_hash": HUB_HASH_HEX}))
+ assert response.status == 200
+ data = json.loads(response.body)
+ assert data["message"] == "Room list requested"
+ assert "hub" in data
+ assert calls == [("/list", None, False)]
+
+
+@pytest.mark.asyncio
+async def test_rrc_rooms_list_disconnected_returns_400(mock_app):
+ post_hub = _find_handler(mock_app, "/api/v1/rrc/hubs", "POST")
+ await post_hub(_make_request(json_body={"hub_hash": HUB_HASH_HEX}))
+
+ handler = _find_handler(mock_app, "/api/v1/rrc/hubs/{hub_hash}/rooms/list", "POST")
+ response = await handler(_make_request(match_info={"hub_hash": HUB_HASH_HEX}))
+ assert response.status == 400
+ data = json.loads(response.body)
+ assert "not connected" in data["message"]

diff --git a/tests/backend/test_rrc_oracle_bugs.py b/tests/backend/test_rrc_oracle_bugs.py
index d3e9599e..6c4974f8 100644
--- a/tests/backend/test_rrc_oracle_bugs.py
+++ b/tests/backend/test_rrc_oracle_bugs.py
@@ -207,6 +207,9 @@ def test_oracle_client_kick_error_leaves_joined_room(tmp_path):
hub = manager.add_hub(HUB_HASH, name="Client")
hub.rooms.add("lobby")
hub.members["lobby"] = {b"\x11" * 16, b"\xaa" * 16}
+ hub.unread_counts["lobby"] = 4
+ hub.unread_rooms.add("lobby")
+ hub.mention_rooms.add("lobby")
hub._handle_error(
proto.make_envelope(
proto.T_ERROR,
@@ -217,6 +220,33 @@ def test_oracle_client_kick_error_leaves_joined_room(tmp_path):
)
assert "lobby" not in hub.rooms
assert "lobby" not in hub.members
+ assert "lobby" not in hub.unread_counts
+ assert "lobby" not in hub.unread_rooms
+ assert "lobby" not in hub.mention_rooms
+
+
+def test_oracle_invite_only_rollback_clears_unread(tmp_path):
+ """Failed auto-rejoin (+i) must not leave unread badges for a dropped room."""
+ manager = RRCManager(
+ identity=FakeIdentity(b"\x11" * 16),
+ storage_dir=str(tmp_path),
+ )
+ hub = manager.add_hub(HUB_HASH, name="Client")
+ hub.rooms.add("private")
+ hub.unread_counts["private"] = 2
+ hub.unread_rooms.add("private")
+ hub._pending_joins.add("private")
+ hub._handle_error(
+ proto.make_envelope(
+ proto.T_ERROR,
+ src=HUB_HASH,
+ room="private",
+ body="invite-only (+i)",
+ ),
+ )
+ assert "private" not in hub.rooms
+ assert "private" not in hub.unread_counts
+ assert "private" not in hub.unread_rooms
def test_oracle_register_room_key_strips_like_join_paths():
@@ -517,3 +547,74 @@ def test_property_keyed_join_rejects_non_matching_keys(wrong):
)
assert "vault" not in sess.rooms
assert server.rooms.ensure_state("vault").get("founder") is None
+
+
+def _oracle_available_rooms_diff(previous, next_rooms):
+ """Independent oracle for /list snapshot replace semantics."""
+ prev = previous if isinstance(previous, dict) else {}
+ nxt = next_rooms if isinstance(next_rooms, dict) else {}
+ added = sorted(k for k in nxt if k not in prev)
+ removed = sorted(k for k in prev if k not in nxt)
+ updated = sorted(
+ k for k in nxt if k in prev and (prev.get(k) or None) != (nxt.get(k) or None)
+ )
+ return added, removed, updated
+
+
+@given(
+ previous=st.dictionaries(
+ st.from_regex(r"[a-z0-9_-]{1,12}", fullmatch=True),
+ st.one_of(st.none(), st.from_regex(r"[A-Za-z0-9 _-]{0,24}", fullmatch=True)),
+ max_size=8,
+ ),
+ next_rooms=st.dictionaries(
+ st.from_regex(r"[a-z0-9_-]{1,12}", fullmatch=True),
+ st.one_of(st.none(), st.from_regex(r"[A-Za-z0-9 _-]{0,24}", fullmatch=True)),
+ max_size=8,
+ ),
+)
+@settings(max_examples=60, deadline=None)
+def test_oracle_list_notice_replaces_available_rooms(
+ tmp_path_factory, previous, next_rooms
+):
+ """Hub /list notices replace available_rooms. Adds and removals both apply."""
+ manager = RRCManager(
+ identity=FakeIdentity(b"\x11" * 16),
+ storage_dir=str(tmp_path_factory.mktemp("rrc-list")),
+ get_nickname=(lambda: None),
+ )
+ hub = manager.add_hub(HUB_HASH)
+ hub.available_rooms = dict(previous)
+ hub._silent_list_pending = 1
+
+ if not next_rooms:
+ body = "No public rooms registered"
+ else:
+ lines = ["Registered public rooms"]
+ for name, topic in sorted(next_rooms.items()):
+ if topic:
+ lines.append(f"{name} - {topic}")
+ else:
+ lines.append(name)
+ body = "\n".join(lines)
+
+ env = proto.make_envelope(proto.T_NOTICE, src=None, body=body)
+ hub._on_packet(proto.encode(env))
+
+ expected = {}
+ for name, topic in next_rooms.items():
+ if isinstance(topic, str):
+ stripped = topic.strip()
+ expected[name] = stripped or None
+ else:
+ expected[name] = None
+ assert hub.available_rooms == expected
+ added, removed, updated = _oracle_available_rooms_diff(previous, expected)
+ for name in added:
+ assert name in hub.available_rooms
+ assert name not in previous
+ for name in removed:
+ assert name not in hub.available_rooms
+ assert name in previous
+ for name in updated:
+ assert hub.available_rooms[name] != (previous.get(name) or None)

diff --git a/tests/backend/test_rrc_protocol.py b/tests/backend/test_rrc_protocol.py
index 2138eaf0..cc47d8d6 100644
--- a/tests/backend/test_rrc_protocol.py
+++ b/tests/backend/test_rrc_protocol.py
@@ -368,6 +368,70 @@ def test_set_auto_list_does_not_list_before_hub_is_connected(tmp_path):
assert calls == []
+def test_request_room_list_sends_silent_list(tmp_path):
+ manager = make_manager(tmp_path)
+ hub = manager.add_hub(bytes(range(16)))
+ calls = []
+ hub.send_command = lambda text, room=None, record_local=True: calls.append(
+ (text, room, record_local),
+ )
+
+ hub.request_room_list()
+
+ assert calls == [("/list", None, False)]
+ assert hub._silent_list_pending == 1
+
+
+def test_request_room_list_raises_when_disconnected(tmp_path):
+ manager = make_manager(tmp_path)
+ hub = manager.add_hub(bytes(range(16)))
+
+ with pytest.raises(RuntimeError, match="not connected"):
+ hub.request_room_list()
+
+ assert hub._silent_list_pending == 0
+
+
+def test_hub_list_notice_replaces_available_rooms(tmp_path):
+ """A /list reply fully replaces available_rooms (add, remove, topic update)."""
+ manager = make_manager(tmp_path)
+ hub = manager.add_hub(bytes(range(16)))
+ hub.available_rooms = {"lobby": "Main", "gone": None, "kept": "Old"}
+
+ env = proto.make_envelope(
+ proto.T_NOTICE,
+ src=None,
+ body="Registered public rooms\nlobby - Renamed\nkept - Old\nnewroom",
+ )
+ hub._on_packet(proto.encode(env))
+
+ assert hub.available_rooms == {
+ "lobby": "Renamed",
+ "kept": "Old",
+ "newroom": None,
+ }
+ assert "gone" not in hub.available_rooms
+
+
+def test_hub_empty_list_notice_clears_available_rooms(tmp_path):
+ manager = make_manager(tmp_path)
+ hub = manager.add_hub(bytes(range(16)))
+ hub.available_rooms = {"lobby": "Main", "random": None}
+ hub._silent_list_pending = 1
+
+ env = proto.make_envelope(
+ proto.T_NOTICE,
+ src=None,
+ body="No public rooms registered",
+ )
+ hub._on_packet(proto.encode(env))
+
+ assert hub.available_rooms == {}
+ assert hub._silent_list_pending == 0
+ for msgs in hub.messages.values():
+ assert all(m.text != "No public rooms registered" for m in msgs)
+
+
def test_history_is_persisted_and_reloaded(tmp_path):
manager = make_manager(tmp_path)
hub = manager.add_hub(bytes(range(16)))

diff --git a/tests/frontend/RelayChatPage.test.js b/tests/frontend/RelayChatPage.test.js
index ad8172a8..1aa8ebc3 100644
--- a/tests/frontend/RelayChatPage.test.js
+++ b/tests/frontend/RelayChatPage.test.js
@@ -181,6 +181,79 @@ describe("RelayChatPage.vue", () => {
});
});
+ it("refreshes available rooms via the rooms list API", async () => {
+ const ToastUtils = (await import("@/js/ToastUtils")).default;
+ vi.spyOn(ToastUtils, "info").mockImplementation(() => {});
+ vi.spyOn(ToastUtils, "error").mockImplementation(() => {});
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/rrc/hubs") {
+ return Promise.resolve({
+ data: {
+ hubs: [
+ makeHub({
+ connected: true,
+ available_rooms: { lobby: "Main", random: null },
+ }),
+ ],
+ },
+ });
+ }
+ if (url === "/api/v1/rrc/servers") {
+ return Promise.resolve({ data: { hubs: [] } });
+ }
+ if (url === "/api/v1/announces") {
+ return Promise.resolve({ data: { announces: [] } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ axiosMock.post.mockResolvedValueOnce({
+ data: { message: "Room list requested", hub: makeHub() },
+ });
+
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
+
+ await wrapper.vm.refreshAvailableRooms(wrapper.vm.hubs[0]);
+
+ expect(axiosMock.post).toHaveBeenCalledWith(`/api/v1/rrc/hubs/${HUB_HASH}/rooms/list`);
+ expect(ToastUtils.info).toHaveBeenCalled();
+ expect(wrapper.vm.isRefreshingAvailableRooms(HUB_HASH)).toBe(false);
+ });
+
+ it("does not request a room list refresh when the hub is disconnected", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/rrc/hubs") {
+ return Promise.resolve({
+ data: {
+ hubs: [
+ makeHub({
+ connected: false,
+ status: 0,
+ available_rooms: { random: null },
+ }),
+ ],
+ },
+ });
+ }
+ if (url === "/api/v1/rrc/servers") {
+ return Promise.resolve({ data: { hubs: [] } });
+ }
+ if (url === "/api/v1/announces") {
+ return Promise.resolve({ data: { announces: [] } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
+ axiosMock.post.mockClear();
+
+ await wrapper.vm.refreshAvailableRooms(wrapper.vm.hubs[0]);
+
+ expect(axiosMock.post).not.toHaveBeenCalled();
+ });
+
it("includes an optional room key when joining a room", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));

diff --git a/tests/frontend/crossSurfaceIdentityUnreadOracle.test.js b/tests/frontend/crossSurfaceIdentityUnreadOracle.test.js
new file mode 100644
index 00000000..718715b5
--- /dev/null
+++ b/tests/frontend/crossSurfaceIdentityUnreadOracle.test.js
@@ -0,0 +1,173 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Oracle tests for unread badge / DND / identity-switch leakage across
+ * conversations, notifications, relay, nomad, rnsh, and map.
+ */
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import MessagesPage from "../../meshchatx/src/frontend/components/messages/MessagesPage.vue";
+import App from "../../meshchatx/src/frontend/components/App.vue";
+import GlobalState from "../../meshchatx/src/frontend/js/GlobalState";
+import { isPrivateOrLocalHostname } from "../../meshchatx/src/frontend/js/mapLocalUrl.js";
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ loading: vi.fn(),
+ dismiss: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/NotificationUtils", () => ({
+ default: {
+ clearMessageNotifications: vi.fn(),
+ showNewMessageNotification: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/NotificationSoundUtils", () => ({
+ default: {
+ play: vi.fn(async () => false),
+ },
+}));
+
+describe("conversations unread oracle", () => {
+ beforeEach(() => {
+ GlobalState.unreadConversationsCount = 3;
+ window.api = {
+ post: vi.fn(async () => ({ data: {} })),
+ };
+ });
+
+ it("does not decrement nav unread when dismissing an already-read conversation", async () => {
+ // Oracle: badge count may only drop when the conversation was unread.
+ // Re-selecting an open read thread must not burn a badge count.
+ const peer = "aa".repeat(16);
+ const ctx = {
+ conversations: [{ destination_hash: peer, is_unread: false }],
+ selectedPeer: { destination_hash: peer, is_unread: false },
+ paneViewers: {},
+ focusedPaneId: 0,
+ };
+
+ MessagesPage.methods.dismissUnreadForOpenDestination.call(ctx, peer);
+ await vi.waitFor(() => expect(window.api.post).toHaveBeenCalled());
+
+ expect(GlobalState.unreadConversationsCount).toBe(3);
+ });
+
+ it("decrements nav unread once when dismissing an unread conversation without a viewer", async () => {
+ const peer = "bb".repeat(16);
+ const conversation = { destination_hash: peer, is_unread: true };
+ const ctx = {
+ conversations: [conversation],
+ selectedPeer: conversation,
+ paneViewers: {},
+ focusedPaneId: 0,
+ };
+
+ MessagesPage.methods.dismissUnreadForOpenDestination.call(ctx, peer);
+ await vi.waitFor(() => expect(window.api.post).toHaveBeenCalled());
+
+ expect(conversation.is_unread).toBe(false);
+ expect(GlobalState.unreadConversationsCount).toBe(2);
+ });
+});
+
+describe("notifications DND unread oracle", () => {
+ it("still refreshes unread badge under DND (DND suppresses OS/sound only)", async () => {
+ // Oracle: DND must not freeze the Messages nav badge while other pages are open.
+ const updateUnreadConversationsCount = vi.fn();
+ const ctx = {
+ config: { do_not_disturb_enabled: true },
+ updateUnreadConversationsCount,
+ updateRelayChatUnreadCount: vi.fn(),
+ };
+
+ const handlers = App.methods.getShellWsHandlers.call(ctx);
+ await handlers["lxmf.delivery"]({
+ sieve_suppress_notifications: false,
+ remote_identity_name: "Peer",
+ remote_identity_hash: "cc".repeat(16),
+ lxmf_message: {
+ is_incoming: true,
+ source_hash: "cc".repeat(16),
+ title: "hi",
+ content: "hello",
+ },
+ });
+
+ expect(updateUnreadConversationsCount).toHaveBeenCalled();
+ });
+
+ it("still skips unread refresh for sieve-suppressed non-user-facing deliveries", async () => {
+ const updateUnreadConversationsCount = vi.fn();
+ const ctx = {
+ config: { do_not_disturb_enabled: false },
+ updateUnreadConversationsCount,
+ };
+ const handlers = App.methods.getShellWsHandlers.call(ctx);
+ await handlers["lxmf.delivery"]({
+ sieve_suppress_notifications: true,
+ lxmf_message: {
+ is_incoming: true,
+ source_hash: "dd".repeat(16),
+ title: "",
+ content: "",
+ fields: { telemetry: {} },
+ },
+ });
+ expect(updateUnreadConversationsCount).not.toHaveBeenCalled();
+ });
+});
+
+describe("map local URL oracle", () => {
+ it("treats only RFC1918 and loopback hosts as local, not all 172.*", () => {
+ // Oracle: 172.16.0.0/12 is private. 172.15.x and 172.32.x are not.
+ expect(isPrivateOrLocalHostname("127.0.0.1")).toBe(true);
+ expect(isPrivateOrLocalHostname("10.1.2.3")).toBe(true);
+ expect(isPrivateOrLocalHostname("192.168.1.1")).toBe(true);
+ expect(isPrivateOrLocalHostname("172.16.0.1")).toBe(true);
+ expect(isPrivateOrLocalHostname("172.31.255.255")).toBe(true);
+ expect(isPrivateOrLocalHostname("172.15.0.1")).toBe(false);
+ expect(isPrivateOrLocalHostname("172.32.0.1")).toBe(false);
+ expect(isPrivateOrLocalHostname("8.8.8.8")).toBe(false);
+ expect(isPrivateOrLocalHostname("tiles.openstreetmap.org")).toBe(false);
+ });
+});
+
+describe("identity-switch surface contracts", () => {
+ function readFrontend(relativePath) {
+ const { readFileSync } = require("fs");
+ const { join } = require("path");
+ return readFileSync(join(process.cwd(), "meshchatx/src/frontend", relativePath), "utf8");
+ }
+
+ it("RelayChatPage listens for identity-switched and clears hub UI state", () => {
+ const src = readFrontend("components/relay/RelayChatPage.vue");
+ expect(src).toContain('GlobalEmitter.on("identity-switched"');
+ expect(src).toMatch(/onIdentitySwitched/);
+ expect(src).toMatch(/this\.hubs\s*=\s*\[\]/);
+ });
+
+ it("RNSHManagerPage listens for identity-switched and clears session output cache", () => {
+ const src = readFrontend("components/tools/RNSHManagerPage.vue");
+ expect(src).toContain('GlobalEmitter.on("identity-switched"');
+ expect(src).toMatch(/outputsBySession\s*=\s*\{\}/);
+ });
+
+ it("NomadNetworkBrowser listens for identity-switched and resets tabs", () => {
+ const src = readFrontend("components/nomadnetwork/NomadNetworkBrowser.vue");
+ expect(src).toContain('GlobalEmitter.on("identity-switched"');
+ expect(src).toMatch(/this\.tabs\s*=\s*\[\]/);
+ });
+
+ it("MapBrowser listens for identity-switched and resets tabs", () => {
+ const src = readFrontend("components/map/MapBrowser.vue");
+ expect(src).toContain('GlobalEmitter.on("identity-switched"');
+ expect(src).toMatch(/this\.tabs\s*=\s*\[\]/);
+ });
+});

diff --git a/tests/frontend/exploratoryFollowUpOracle.test.js b/tests/frontend/exploratoryFollowUpOracle.test.js
new file mode 100644
index 00000000..ba807aef
--- /dev/null
+++ b/tests/frontend/exploratoryFollowUpOracle.test.js
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: 0BSD
+
+import { readFileSync } from "fs";
+import { join } from "path";
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { rnodeIntegrityKeyForSrc } from "../../meshchatx/src/frontend/js/rnode/rnodeIntegrityKey.js";
+import MessagesPage from "../../meshchatx/src/frontend/components/messages/MessagesPage.vue";
+import MapPage from "../../meshchatx/src/frontend/components/map/MapPage.vue";
+import GlobalState from "../../meshchatx/src/frontend/js/GlobalState";
+import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
+
+describe("follow-up oracles from exploratory hunt", () => {
+ it("RNode SRI key for zip.min.js matches integrity.json (not js/zip.min.js)", () => {
+ const integrity = JSON.parse(
+ readFileSync(join(process.cwd(), "meshchatx/src/frontend/public/rnode-flasher/js/integrity.json"), "utf8")
+ ).files;
+ const key = rnodeIntegrityKeyForSrc("/rnode-flasher/js/zip.min.js", integrity);
+ expect(key).toBe("zip.min.js");
+ expect(integrity[key]).toMatch(/^sha384-/);
+
+ const nested = rnodeIntegrityKeyForSrc("/rnode-flasher/js/crypto-js@3.9.1-1/core.js", integrity);
+ expect(nested).toBe("crypto-js@3.9.1-1/core.js");
+
+ const dist = rnodeIntegrityKeyForSrc("/rnode-flasher/js/web-serial-polyfill@1.0.15/dist/serial.js", integrity);
+ expect(dist).toBe("web-serial-polyfill@1.0.15/dist/serial.js");
+ });
+
+ it("MessagesPage syncUnreadCount does not overwrite badge with a partial page count", () => {
+ GlobalState.unreadConversationsCount = 12;
+ const emitSpy = vi.spyOn(GlobalEmitter, "emit");
+ const ctx = {
+ conversations: [{ is_unread: true }, { is_unread: true }, { is_unread: false }],
+ hasMoreConversations: true,
+ filterUnreadOnly: false,
+ selectedFolderId: null,
+ conversationSearchTerm: "",
+ };
+
+ MessagesPage.methods.syncUnreadCount.call(ctx);
+
+ expect(GlobalState.unreadConversationsCount).toBe(12);
+ expect(emitSpy).toHaveBeenCalledWith("notifications-changed");
+ emitSpy.mockRestore();
+ });
+
+ it("Map resolveMyLocationWgs84 prefers lxmf_address_hash telemetry over identity_hash", async () => {
+ const lx = "aa".repeat(16);
+ const id = "bb".repeat(16);
+ const ctx = {
+ config: {
+ location_source: "browser",
+ lxmf_address_hash: lx,
+ identity_hash: id,
+ },
+ telemetryList: [
+ {
+ destination_hash: lx,
+ telemetry: { location: { longitude: 11.1, latitude: 22.2 } },
+ },
+ ],
+ };
+
+ const loc = await MapPage.methods.resolveMyLocationWgs84.call(ctx);
+ expect(loc).toEqual({ lon: 11.1, lat: 22.2 });
+ });
+});

diff --git a/tests/frontend/rrcAvailableRooms.oracle.test.js b/tests/frontend/rrcAvailableRooms.oracle.test.js
new file mode 100644
index 00000000..07d3a4b9
--- /dev/null
+++ b/tests/frontend/rrcAvailableRooms.oracle.test.js
@@ -0,0 +1,118 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Oracle tests for RRC available-rooms refresh sync.
+ *
+ * Guarantee: a hub /list snapshot fully replaces available_rooms. The
+ * Available Rooms UI shows only unjoined rooms from that map.
+ */
+import { describe, expect, it } from "vitest";
+import { applyAvailableRoomsSnapshot, diffAvailableRooms, unjoinedAvailableRooms } from "@/js/rrcAvailableRooms.js";
+
+/** Independent oracle for unjoined sidebar rows. */
+function oracleUnjoined(availableRooms, knownRooms) {
+ const known = new Set(Array.isArray(knownRooms) ? knownRooms : []);
+ if (!availableRooms || typeof availableRooms !== "object") {
+ return [];
+ }
+ return Object.keys(availableRooms)
+ .filter((name) => typeof name === "string" && name && !known.has(name))
+ .sort()
+ .map((name) => {
+ const topic = availableRooms[name];
+ return {
+ name,
+ topic: typeof topic === "string" && topic ? topic : null,
+ };
+ });
+}
+
+/** Independent oracle for add/remove/topic-update after a list replace. */
+function oracleDiff(previous, next) {
+ const prev = previous && typeof previous === "object" && !Array.isArray(previous) ? previous : {};
+ const nxt = next && typeof next === "object" && !Array.isArray(next) ? next : {};
+ const added = Object.keys(nxt)
+ .filter((k) => !(k in prev))
+ .sort();
+ const removed = Object.keys(prev)
+ .filter((k) => !(k in nxt))
+ .sort();
+ const updated = Object.keys(nxt)
+ .filter((k) => k in prev && (prev[k] || null) !== (nxt[k] || null))
+ .sort();
+ return { added, removed, updated };
+}
+
+describe("rrcAvailableRooms oracles", () => {
+ it("oracle: unjoined rooms exclude known rooms and sort by name", () => {
+ const available = { zebra: null, lobby: "Main", alpha: "A" };
+ const known = ["lobby"];
+ expect(unjoinedAvailableRooms(available, known)).toEqual(oracleUnjoined(available, known));
+ expect(unjoinedAvailableRooms(available, known)).toEqual([
+ { name: "alpha", topic: "A" },
+ { name: "zebra", topic: null },
+ ]);
+ });
+
+ it("oracle: empty or invalid available maps yield no unjoined rooms", () => {
+ expect(unjoinedAvailableRooms(null, ["lobby"])).toEqual([]);
+ expect(unjoinedAvailableRooms(undefined, ["lobby"])).toEqual([]);
+ expect(unjoinedAvailableRooms([], ["lobby"])).toEqual([]);
+ expect(unjoinedAvailableRooms({ lobby: "Main" }, ["lobby"])).toEqual([]);
+ });
+
+ it("oracle: list refresh replace adds new rooms and removes gone ones", () => {
+ const previous = { lobby: "Main", gone: null, kept: "Old" };
+ const next = { lobby: "Renamed", kept: "Old", fresh: null };
+ const applied = applyAvailableRoomsSnapshot(previous, next);
+ expect(applied).toEqual(next);
+ expect(diffAvailableRooms(previous, applied)).toEqual(oracleDiff(previous, applied));
+ expect(diffAvailableRooms(previous, applied)).toEqual({
+ added: ["fresh"],
+ removed: ["gone"],
+ updated: ["lobby"],
+ });
+ });
+
+ it("oracle: empty list snapshot clears all available rooms", () => {
+ const previous = { lobby: "Main", random: null };
+ const applied = applyAvailableRoomsSnapshot(previous, {});
+ expect(applied).toEqual({});
+ expect(diffAvailableRooms(previous, applied)).toEqual({
+ added: [],
+ removed: ["lobby", "random"],
+ updated: [],
+ });
+ });
+
+ it("fuzz: random maps match independent unjoined and diff oracles", () => {
+ const names = ["a", "b", "c", "d", "e", "f"];
+ for (let i = 0; i < 80; i++) {
+ const available = {};
+ const known = [];
+ const next = {};
+ for (const name of names) {
+ if (Math.random() < 0.5) {
+ available[name] = Math.random() < 0.5 ? `t-${name}` : null;
+ }
+ if (Math.random() < 0.35) {
+ known.push(name);
+ }
+ if (Math.random() < 0.5) {
+ next[name] = Math.random() < 0.5 ? `n-${name}` : null;
+ }
+ }
+ expect(unjoinedAvailableRooms(available, known)).toEqual(oracleUnjoined(available, known));
+ const applied = applyAvailableRoomsSnapshot(available, next);
+ expect(diffAvailableRooms(available, applied)).toEqual(oracleDiff(available, applied));
+ for (const name of Object.keys(available)) {
+ if (!(name in next)) {
+ expect(applied).not.toHaveProperty(name);
+ }
+ }
+ for (const name of Object.keys(next)) {
+ expect(applied[name]).toBe(next[name]);
+ }
+ }
+ });
+});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────